"use client" import * as React from 'react' import { useRouter } from 'next/navigation' import { ArrowRight, Bot, Check, ChevronRight, CircleCheck, Inbox, Info, Code2, Wrench, } from 'lucide-react' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { Button } from '@open-mercato/ui/primitives/button' import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay' import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context' import { confidenceFace, confidencePctOf } from '../../../components/cockpitStatus' import { shortCaseId } from '../../../components/subjectRef' import { isAgentPreviewUiEnabled } from '../../../lib/featureFlags' import { formatConfidence, formatCostMinor, formatDurationMs, formatNumber, formatTimeShort, mapProposal, mapRun, type ProposalView, type RunView, } from '../../../components/types' import type { MilestoneStage } from '../../../lib/tasks/milestones' import type { ProcessOutcome } from '../../../lib/tasks/outcome' import { ProcessOutcomeLink } from '../../../components/ProcessOutcomeLink' import { mapProcessProjection, type ProcessInstanceStatus, type ProcessActorKind, type ProcessDetailSectionKind, type ProcessProjection, type ProcessStage, type ProcessStateTone, type ProcessStep, } from '../../../components/processTypes' type StageState = 'done' | 'current' | 'upcoming' const STATE_DOT: Record = { neutral: 'bg-muted-foreground', info: 'bg-status-info-text', success: 'bg-status-success-text', warning: 'bg-status-warning-text', error: 'bg-status-error-text', } // Maps ProcessInstanceStatus → state pill tone + i18n label (spec status derivation). const STATUS_TONE: Record = { running: 'info', waiting_on_you: 'warning', question_open: 'info', docs_requested: 'warning', fraud_hold: 'error', auto_completing: 'info', auto_completed: 'success', completed: 'success', failed: 'error', cancelled: 'neutral', } const STATUS_LABEL_KEY: Record = { running: 'agent_orchestrator.process.status.running', waiting_on_you: 'agent_orchestrator.process.status.waitingOnYou', question_open: 'agent_orchestrator.process.status.questionOpen', docs_requested: 'agent_orchestrator.process.status.docsRequested', fraud_hold: 'agent_orchestrator.process.status.fraudHold', auto_completing: 'agent_orchestrator.process.status.autoCompleting', auto_completed: 'agent_orchestrator.process.status.autoCompleted', completed: 'agent_orchestrator.process.status.completed', failed: 'agent_orchestrator.process.status.failed', cancelled: 'agent_orchestrator.process.status.cancelled', } // agent proposes (brand-violet) vs system/Open Mercato disposes (foreground) — // kept far apart in hue so the two roles read at a glance. const ACTOR_DOT: Record = { agent: 'bg-brand-violet', system: 'bg-foreground', } const ACTOR_TEXT: Record = { agent: 'text-brand-violet', system: 'text-foreground', } function formatSubjectValue(minor: number | null, currency: string | null, locale: string): string | null { if (minor == null) return null const major = formatNumber(minor / 100, locale) return major == null ? null : `${major}${currency ? ` ${currency}` : ''}` } function formatAge(iso: string | null, t: ReturnType): string | null { if (!iso) return null const parsed = Date.parse(iso) if (!Number.isFinite(parsed)) return null const diff = Math.max(0, Date.now() - parsed) const days = Math.floor(diff / (24 * 60 * 60 * 1000)) const hours = Math.floor((diff % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)) const minutes = Math.floor((diff % (60 * 60 * 1000)) / (60 * 1000)) if (days > 0) return t('agent_orchestrator.process.relTimeDh', undefined, { days, hours }) if (hours > 0) return t('agent_orchestrator.process.relTimeH', undefined, { hours }) return t('agent_orchestrator.process.relTimeM', undefined, { minutes }) } /** Calendar-day key used for the timeline's day dividers. */ function dayKeyOf(iso: string | null): string { if (!iso) return '' return iso.slice(0, 10) } function dayLabelOf(dayKey: string, t: ReturnType): string { const today = new Date().toISOString().slice(0, 10) const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10) if (dayKey === today) return t('agent_orchestrator.process.today') if (dayKey === yesterday) return t('agent_orchestrator.process.yesterday') return dayKey } function summarize(proposal: ProposalView, t: ReturnType): string { if (proposal.rationale) return proposal.rationale return t('agent_orchestrator.process.stepProposed') } /** * Builds the propose/dispose timeline from real proposals (+ their runs for the * confidence/latency/cost metrics). One agent step per proposal; disposed * proposals additionally emit a system "disposed" step at their updatedAt. */ function buildSteps( proposals: ProposalView[], runsById: Map, t: ReturnType, ): ProcessStep[] { const steps: ProcessStep[] = [] for (const proposal of proposals) { const run = runsById.get(proposal.runId) ?? null steps.push({ id: `${proposal.id}:proposed`, runId: proposal.runId, proposalId: proposal.id, disposition: proposal.disposition, agentId: proposal.agentId, stepId: proposal.stepId, actor: proposal.agentId, actorKind: 'agent', summary: summarize(proposal, t), time: formatTimeShort(proposal.createdAt) ?? '', day: dayKeyOf(proposal.createdAt), detail: { confidence: formatConfidence(proposal.confidence ?? run?.confidence ?? null), latency: formatDurationMs(run?.latencyMs ?? null), cost: formatCostMinor(run?.costMinor ?? null, run?.currency ?? null), sections: [], payload: proposal.payload, }, }) if (proposal.disposition !== 'pending') { steps.push({ id: `${proposal.id}:disposed`, runId: proposal.runId, proposalId: proposal.id, disposition: proposal.disposition, agentId: null, stepId: proposal.stepId, actor: t('agent_orchestrator.process.disposes'), actorKind: 'system', summary: t('agent_orchestrator.process.stepDisposed', undefined, { disposition: t(`agent_orchestrator.disposition.${proposal.disposition}`), }), time: formatTimeShort(proposal.updatedAt ?? proposal.createdAt) ?? '', day: dayKeyOf(proposal.updatedAt ?? proposal.createdAt), detail: { confidence: proposal.dispositionBy?.startsWith('rule:') ? 'auto' : null, latency: null, cost: null, sections: [], payload: { disposition: proposal.disposition, dispositionBy: proposal.dispositionBy, dispositionReason: proposal.dispositionReason, }, }, }) } } return steps } /** * Ordered distinct workflow step ids observed on the process's activity — the * fallback when the process's definition declares no milestones. Raw step ids * are the Studio's vocabulary, not a business reader's; the milestone list * above replaces them whenever one is authored. */ function buildStages(proposals: ProposalView[], currentStage: string | null): ProcessStage[] { const seen = new Set() const stages: ProcessStage[] = [] for (const proposal of proposals) { if (proposal.stepId && !seen.has(proposal.stepId)) { seen.add(proposal.stepId) stages.push({ key: proposal.stepId, label: proposal.stepId }) } } if (currentStage && !seen.has(currentStage)) { stages.push({ key: currentStage, label: currentStage }) } return stages } const SECTION_META: Record< ProcessDetailSectionKind, { icon: React.ComponentType<{ className?: string }>; titleKey: string; success?: boolean } > = { input: { icon: Inbox, titleKey: 'agent_orchestrator.process.sectionInput' }, tools: { icon: Wrench, titleKey: 'agent_orchestrator.process.sectionTools' }, output: { icon: CircleCheck, titleKey: 'agent_orchestrator.process.sectionOutput', success: true }, } function DetailMetric({ label, value, face, }: { label: string value: string | null face?: { Icon: React.ComponentType<{ className?: string }>; color: string } | null }) { const FaceIcon = face?.Icon return (

{label}

{FaceIcon ? : null} {value ?? '—'}

) } function StepDetailPanel({ step, dayLabel, onOpenTrace, onReviewInCaseload, }: { step: ProcessStep dayLabel: string onOpenTrace: () => void onReviewInCaseload?: () => void }) { const t = useT() const roleLabel = t( step.actorKind === 'agent' ? 'agent_orchestrator.process.proposes' : 'agent_orchestrator.process.disposes', ) // Confidence is a pre-formatted string ("0.95" / "auto"); a numeric one gets a face. const confNum = step.detail.confidence != null ? Number(step.detail.confidence) : Number.NaN const confFace = Number.isFinite(confNum) ? confidenceFace(confidencePctOf(confNum)!) : null return (
{roleLabel} {dayLabel} {step.time}

{step.actor}

{step.detail.sections.map((section) => { const meta = SECTION_META[section.kind] const Icon = meta.icon return (
{t(meta.titleKey)}
{section.rows.length}
{section.rows.map((row) => (
{row.label}
{row.value}
))}
) })} {step.detail.sections.length === 0 ? (

{t('agent_orchestrator.process.detail.payloadOnly')}

) : null}
{t('agent_orchestrator.process.outputPayload')}
json
{step.disposition === 'pending' && step.proposalId && onReviewInCaseload ? ( ) : null}
) } type ListResponse = { items?: Array> } export default function ProcessDetailPage({ params }: { params?: { id?: string } }) { const t = useT() const locale = useLocale() const router = useRouter() const workflowInstanceId = params?.id ?? '' const [projection, setProjection] = React.useState(null) const [milestoneStages, setMilestoneStages] = React.useState([]) const [outcome, setOutcome] = React.useState<{ value: ProcessOutcome; href: string | null } | null>(null) const [degraded, setDegraded] = React.useState(false) const [proposals, setProposals] = React.useState([]) const [runsById, setRunsById] = React.useState>(new Map()) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState(null) React.useEffect(() => { let cancelled = false async function load() { setIsLoading(true) setError(null) // ONE read for the whole business view. The execution endpoint already // composes the projection, the milestones it reached against the declared // vocabulary, and the outcome with its resolved href — three round trips // that used to be made here, and three chances for them to disagree. const [headerCall, proposalsCall] = await Promise.all([ apiCall<{ execution?: Record milestones?: MilestoneStage[] outcome?: (ProcessOutcome & { href: string | null }) | null }>( `/api/agent_orchestrator/executions/${encodeURIComponent(workflowInstanceId)}`, undefined, { fallback: {} }, ), apiCall( `/api/agent_orchestrator/proposals?workflowInstanceId=${encodeURIComponent(workflowInstanceId)}&pageSize=100&sortField=createdAt&sortDir=asc`, undefined, { fallback: { items: [] } }, ), ]) if (cancelled) return const proposalRows = (Array.isArray(proposalsCall.result?.items) ? proposalsCall.result.items : []) .map((row) => mapProposal(row)) .filter((row): row is ProposalView => !!row) const header = headerCall.ok && headerCall.result?.execution ? mapProcessProjection(headerCall.result.execution) : null if (!header && proposalRows.length === 0) { setError(t('agent_orchestrator.process.detail.error')) setIsLoading(false) return } // Header degradation (spec): no projection row yet → render the process by // its id from activity data alone, clearly hinted, instead of failing. const fallbackHeader: ProcessProjection = { workflowInstanceId, workflowId: null, workflowVersion: null, subjectType: null, subjectId: null, subjectLabel: null, subjectTitle: null, subjectValueMinor: null, subjectFraud: null, subjectFacets: null, status: proposalRows.some((p) => p.disposition === 'pending') ? 'waiting_on_you' : 'running', currentStage: proposalRows[proposalRows.length - 1]?.stepId ?? null, agentIds: Array.from(new Set(proposalRows.map((p) => p.agentId))), costMinor: null, currency: null, runCount: new Set(proposalRows.map((p) => p.runId)).size, pendingProposalCount: proposalRows.filter((p) => p.disposition === 'pending').length, assigneeUserId: null, teamId: null, waitingSince: null, openedAt: proposalRows[0]?.createdAt ?? null, lastActivityAt: proposalRows[proposalRows.length - 1]?.updatedAt ?? null, } setProjection(header ?? fallbackHeader) setDegraded(!header) setProposals(proposalRows) // The business narrative: the declared stages, with the ones the workflow // actually announced marked done. An execution whose process declares none // falls back to the observed-step stepper below. setMilestoneStages(Array.isArray(headerCall.result?.milestones) ? headerCall.result.milestones : []) // What this execution PRODUCED, as the endpoint resolved it. Optional by // decision — a research or monitoring process produces nothing — so an // absent outcome renders nothing at all rather than an empty row. A null // href means the owning module is absent from this deployment and the // label snapshot is all a reader gets. const produced = headerCall.result?.outcome ?? null setOutcome(produced ? { value: produced, href: produced.href ?? null } : null) const runIds = Array.from(new Set(proposalRows.map((p) => p.runId))).filter(Boolean) if (runIds.length > 0) { const runsCall = await apiCall( `/api/agent_orchestrator/runs?ids=${runIds.map((id) => encodeURIComponent(id)).join(',')}&pageSize=${Math.min(runIds.length, 100)}`, undefined, { fallback: { items: [] } }, ) if (!cancelled && runsCall.ok) { const map = new Map() for (const row of runsCall.result?.items ?? []) { const run = mapRun(row) if (run) map.set(run.id, run) } setRunsById(map) } } if (!cancelled) setIsLoading(false) } if (workflowInstanceId) void load() return () => { cancelled = true } }, [workflowInstanceId, t]) const steps = React.useMemo( () => buildSteps(proposals, runsById, t), [proposals, runsById, t], ) const stages = React.useMemo( () => buildStages(proposals, projection?.currentStage ?? null), [proposals, projection], ) const [selectedId, setSelectedId] = React.useState('') // Default focus: the first pending step (the one demanding a decision), else // the newest step — never the oldest, which is history the operator has seen. const selected = steps.find((step) => step.id === selectedId) ?? steps.find((step) => step.disposition === 'pending') ?? steps[steps.length - 1] const openTrace = React.useCallback(() => { if (selected?.runId) { router.push(`/backend/traces/${selected.runId}`) return } router.push('/backend/traces') }, [router, selected]) const reviewSelectedInCaseload = React.useCallback(() => { if (selected?.proposalId) { router.push(`/backend/caseload/${encodeURIComponent(selected.proposalId)}`) } }, [router, selected]) const oldestPendingProposalId = React.useMemo( () => proposals.find((proposal) => proposal.disposition === 'pending')?.id ?? null, [proposals], ) if (isLoading) { return ( ) } if (error || !projection) { return (
) } const process = projection const currentStageIndex = stages.findIndex( (stage) => stage.key === process.currentStage || stage.label === process.currentStage, ) // Terminal processes render every stage as done — no phantom "current" stage // on a completed/failed/cancelled case. const isTerminal = ['auto_completed', 'completed', 'failed', 'cancelled'].includes(process.status) /** * The business-facing milestone view: the declared stages, with what the * workflow announced marked done. Falls back to the raw step ids the activity * observed when the process declares none, so an execution without a milestone * vocabulary still reads as a sequence. */ const stageRows: Array<{ key: string; label: string; state: StageState }> = milestoneStages.length > 0 ? milestoneStages : stages.map((stage, index) => ({ ...stage, state: isTerminal ? 'done' : currentStageIndex < 0 ? 'upcoming' : index < currentStageIndex ? 'done' : index === currentStageIndex ? 'current' : 'upcoming', })) const claimedValue = formatSubjectValue(process.subjectValueMinor, process.currency, locale) const openedAge = formatAge(process.openedAt, t) // Day dividers are emitted inline as the day changes down the trace. let lastDay: string | null = null return (
{degraded ? (

{t('agent_orchestrator.process.detail.degraded')}

) : null} {/* Claim header + stage stepper */}

{process.subjectType ?? t('agent_orchestrator.process.list.title')}{' '} {process.subjectLabel ?? shortCaseId(process.workflowInstanceId)}

{process.subjectTitle ?? process.workflowId ?? process.workflowInstanceId}

{process.status === 'waiting_on_you' && oldestPendingProposalId ? ( ) : null} {isAgentPreviewUiEnabled() ? ( <> ) : null}
{isAgentPreviewUiEnabled() ? (

{t('agent_orchestrator.process.actionsComingSoon')}

) : null}
{(() => { const facets = (process.subjectFacets ?? null) as { subjectParty?: string | null; ownerLabel?: string | null } | null return ( <> {facets?.subjectParty ? ( ) : null} {claimedValue ? ( ) : null} {openedAge ? ( ) : null} {facets?.ownerLabel ? ( ) : null} ) })()}

{t('agent_orchestrator.process.factState')}

{t(STATUS_LABEL_KEY[process.status])}

{/* Absent by design on a research or monitoring process — nothing renders. */} {outcome ? (

{t('agent_orchestrator.process.factOutcome')}

) : null}
{stageRows.length > 0 ? (

{t( milestoneStages.length > 0 ? 'agent_orchestrator.process.milestonesTitle' : 'agent_orchestrator.process.stagesObservedTitle', )}

{stageRows.map((stage, index) => (
{stage.label}
{index < stageRows.length - 1 ? ( ) : null}
))}
) : null}
{/* Split view: activity trace ↔ selected step detail */}

{t('agent_orchestrator.process.activityTrace')}

{t('agent_orchestrator.process.legendProposes')} {t('agent_orchestrator.process.legendDisposes')}
{steps.length === 0 ? (

{t('agent_orchestrator.process.detail.noActivity')}

) : (
{steps.map((step) => { const showDay = step.day !== lastDay lastDay = step.day const isSelected = step.id === selected?.id return ( {showDay ? (

{dayLabelOf(step.day, t)}

) : null}
) })}
)}
{selected ? ( ) : null}
) } function HeaderFact({ label, value }: { label: string; value: string }) { return (

{label}

{value}

) } function StageNode({ state, position }: { state: StageState; position: number }) { if (state === 'done') { return ( ) } if (state === 'current') { return ( {position} ) } return ( {position} ) }